리프레시 토큰 사용 방법

11/15/2024

리프레시 토큰

리프레시 토큰(Refresh Token)은 액세스 토큰이 만료 되었을 때, 클라이언트가 새로 로그인하지 않고도 새로운 액세스 토큰을 발급받기 위한 용도로 사용된다.

만료된 토큰 처리

JWT는 만료 시간이 지나면 더 이상 유효하지 않기 때문에, 만료된 토큰을 처리하는 로직이 필요하다. 이를 위해 리프레시 토큰(Refresh Token)을 사용하는 방법이 일반적이다.

리프레시 토큰 사용 흐름

  • 엑세스 토큰: 짧은 유효기간을 가지며, 만료되면 클라이언트는 서버로부터 새로운 액세스 토큰을 발급 받아야 한다.
  • 리프레시 토큰: 더 긴 유효기간을 가지며, 액세스 토큰이 만료 되었을 때, 클라이언트가 서버에 리프레시 토큰을 보내어 새로운 액세스 토큰을 발급받는다.

구현 방법

리프레시 토큰을 구현하기 위해서는:

  1. 액세스 토큰과 라프레시 토큰을 함께 발급.
  2. 리프레시 토큰을 저장하고, 액세스 토큰이 만료되었을 때 이를 통해 새로운 액세스 토큰을 발급하는 라우트를 추가.

코드 예시

서버코드(app.js)

const express = require('express');
const jwt = require('jsonwebtoken');
const bodyParser = require('body-parser');
const path = require('path');

const app = express();
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());
app.use(express.static('public'));

const ACCESS_TOKEN_SECRET = 'accessTokenSecret';
const REFRESH_TOKEN_SECRET = 'refreshTokenSecret';

// 리프레시 토큰 저장소 (실제 구현에서는 데이터베이스를 사용해야 한다)
const refreshTokens = new Set();

// 사용자 데이터베이스 (예시)
const users = {
	'user1': 'password1',
	'user2': 'password2'
};

// 로그인 페이지 라우트
app.get('/login', (req, res) => {
	res.sendFile(path.join(__dirname, 'public', 'login.html'));
});

// 로그인 처리 (POST 요청)
app.post('/login', (req, res) => {
	const { username, password } = req.body;
	if (users[username] && users[username] === password) {
		const accessToken = generateAccessToken(username);
		const refreshToken = generateRefreshToken(username);
		refreshTokens.add(refreshToken);
		res.json({ accessToken, refreshToken });
	} else {
		res.status(401).send('로그인 실패! 사용자 이름 또는 비밀번호가 잘못되었습니다.');
	}
});

// 액세스 토큰 생성 함수
function generateAccessToken(username) {
	return jwt.sign({ username }, ACCESS_TOKEN_SECRET, { expiresIn: '1m' });
}

// 리프레시 토큰 생성 함수
function generateRefreshToken(username) {
	return jwt.sign({ username }, REFRESH_TOKEN_SECRET, { expiresIn: '7d' });
}

// 토큰 갱신 엔드포인트
app.post('/token', (req, res) => {
	const { refreshToken } = req.body;
	console.log('리프레시 토큰 요청 받음:', refreshToken);
	if (!refreshToken || !refreshTokens.has(refreshToken)) {
		console.log('유효하지 않은 리프레시 토큰');
		return res.status(403).json({ error: '유효하지 않은 리프레시 토큰입니다.' });
	}

	jwt.verify(refreshToken, REFRESH_TOKEN_SECRET, (err, user) => {
		if (err) {
			console.log('리프레시 토큰 검증 실패:', err);
			return res.status(403).json({ error: '리프레시 토큰 검증 실패' });
		}

	const accessToken = generateAccessToken(user.username);
		console.log('새 액세스 토큰 생성:', accessToken);
		res.json({ accessToken });
	});
});

// 로그아웃 처리
app.post('/logout', (req, res) => {
	const { refreshToken } = req.body;
	refreshTokens.delete(refreshToken);
	res.sendStatus(204);
});

// 대시보드 페이지 라우트 (HTML 파일 제공)
app.get('/dashboard', (req, res) => {
	res.sendFile(path.join(__dirname, 'public', 'dashboard.html'));
});

// 대시보드 API 엔드포인트 (인증 필요)
app.get('/api/dashboard', authenticateToken, (req, res) => {
	res.json({ message: `환영합니다, ${req.user.username}님!` });
});

// 미들웨어: 액세스 토큰 검증
function authenticateToken(req, res, next) {
	const authHeader = req.headers['authorization'];
	const token = authHeader && authHeader.split(' ')[1];

	if (!token) {
		return res.status(401).json({ error: '액세스 토큰이 필요합니다.' });
	}

	jwt.verify(token, ACCESS_TOKEN_SECRET, (err, user) => {
		if (err) {
		return res.status(403).json({ error: '유효하지 않은 액세스 토큰입니다.' });
	}
	req.user = user;
	next();
	});
}

app.listen(3000, () => {
	console.log('서버가 http://localhost:3000 에서 실행 중입니다.');
});

로그인페이지(public/login.html)

<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	<meta name="viewport" content="width=device-width, initial-scale=1.0">
	<title>로그인</title>
</head>

<body>
	<h2>로그인</h2>
	<form id="loginForm">
		<label>사용자 이름:</label>
		<input type="text" id="username" />
		<label>비밀번호:</label>
		<input type="password" id="password" />
		<button type="submit">로그인</button>
	</form>

<script>
	document.getElementById('loginForm').addEventListener('submit', async function(event) {
		event.preventDefault();
		const username = document.getElementById('username').value;
		const password = document.getElementById('password').value;

	try {
		const response = await fetch('/login', {
			method: 'POST',
			headers: { 'Content-Type': 'application/json' },
			body: JSON.stringify({ username, password })
		});

		if (!response.ok) {
			throw new Error('로그인 실패');
		}

		const data = await response.json();

		// 액세스 토큰과 리프레시 토큰을 로컬 스토리지에 저장
		localStorage.setItem('accessToken', data.accessToken);
		localStorage.setItem('refreshToken', data.refreshToken);

		alert('로그인 성공! 대시보드로 이동합니다.');
		window.location.href = '/dashboard.html';
	} catch (error) {
		alert(error.message);
	}
});
</script>
</body>
</html>

대시보드페이지(public/dashboard)

<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	<meta name="viewport" content="width=device-width, initial-scale=1.0">
	<title>대시보드</title>
</head>

<body>
	<h2>대시보드</h2>
	<p id="welcomeMessage"></p>
	<button id="logoutButton">로그아웃</button>

	<script>
		async function loadDashboard() {
			const accessToken = localStorage.getItem('accessToken');
			const refreshToken = localStorage.getItem('refreshToken');
			
			if (!accessToken || !refreshToken) {
				console.log('토큰이 없습니다. 로그인 페이지로 이동합니다.');
				window.location.href = '/login.html';
				return;
			}

		try {
			const response = await fetch('/api/dashboard', {
				method: 'GET',
				headers: {
					'Authorization': 'Bearer ' + accessToken
				}
			});

			if (response.status === 403) {
				console.log('액세스 토큰이 만료되었습니다. 새 토큰을 요청합니다.');
				const newAccessToken = await refreshAccessToken(refreshToken);
				if (newAccessToken) {
					console.log('새 액세스 토큰을 받았습니다. 대시보드를 다시 로드합니다.');
					localStorage.setItem('accessToken', newAccessToken);
					return loadDashboard(); // 새로운 액세스 토큰으로 다시 시도
				}
			}

			if (!response.ok) {
				throw new Error('대시보드 접근 실패');
			}

			const data = await response.json();
			console.log('대시보드 로드 성공:', data.message);
			document.getElementById('welcomeMessage').innerText = data.message;
		} catch (error) {
			console.error('오류 발생:', error.message);
			alert(error.message);
			window.location.href = '/login.html';
		}
	}

	async function refreshAccessToken(refreshToken) {
		try {
			const response = await fetch('/token', {
				method: 'POST',
				headers: { 'Content-Type': 'application/json' },
				body: JSON.stringify({ refreshToken })
			});

			if (!response.ok) {
				throw new Error('토큰 갱신 실패');
			}

			const data = await response.json();
			return data.accessToken;
		} catch (error) {
			console.error('리프레시 토큰 사용 실패:', error);
			return null;
		}
	}

	document.getElementById('logoutButton').addEventListener('click', async () => {
		const refreshToken = localStorage.getItem('refreshToken');
		try {
			await fetch('/logout', {
				method: 'POST',
				headers: { 'Content-Type': 'application/json' },
				body: JSON.stringify({ refreshToken })
			});
			localStorage.removeItem('accessToken');
			localStorage.removeItem('refreshToken');
			window.location.href = '/login.html';
		} catch (error) {
			console.error('로그아웃 실패:', error);
		}
	});
	loadDashboard();
	</script>
</body>
</html>

부분 설명

리프레시 토큰 저장소

const refreshTokens = new Set();
  • app.js에서 주석으로 표시한 것과 같이, 실제 서비스에서는 데이터베이스에 저장하는 것이 좋다고 한다.

새로운 액세스 토큰 발급 라우트

async function refreshAccessToken(refreshToken) {
	try {
		const response = await fetch('/token', {
			method: 'POST',
			headers: { 'Content-Type': 'application/json' },
			body: JSON.stringify({ refreshToken })
		});
  • 클라이언트는 /token 경로로 POST 요청을 보내며, 본문에 리프레시 토큰을 포함해야 한다.
  • 서버는 해당 리프레시 토큰이 유효한지 확인하고, 유효하다면 새로운 액세스 토큰을 발급한다.

로그아웃 처리

try {
	await fetch('/logout', {
		method: 'POST',
		headers: { 'Content-Type': 'application/json' },
		body: JSON.stringify({ refreshToken })
	});
	localStorage.removeItem('accessToken');
	localStorage.removeItem('refreshToken');
  • 클라이언트가 로그아웃할 때 /logout 경로로 POST 요청을 보내며 본문에 리프레시 토큰을 포함한다.
  • 서버는 해당 리프레시 토큰을 삭제하여 더 이상 사용할 수 없도록 한다.

블로그 내 관련 문서


참고 자료

출처 :

댓글을 불러오는 중...